Skip to content

Launch gateway hardening: probes, retries, rate limits, forwarding deadlines - #868

Open
lorenzo-norcini-scale wants to merge 28 commits into
mainfrom
lorenzonorcini/launch-gateway-incident-hardening
Open

Launch gateway hardening: probes, retries, rate limits, forwarding deadlines#868
lorenzo-norcini-scale wants to merge 28 commits into
mainfrom
lorenzonorcini/launch-gateway-incident-hardening

Conversation

@lorenzo-norcini-scale

@lorenzo-norcini-scale lorenzo-norcini-scale commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Hardening from the 2026-08-13 gateway incident (per-tenant rate limiting tracked in MLI-8236, sync-forwarder timeout in MLI-8206). Supersedes #864, #865, #866 and #867. All changes validated live on ml-serving training (istiod 1.15.0 / Envoy 1.23, prod parity).

Changes

Readiness (app + chart). healthcheck is now async def, so the /readyz probe no longer queues behind blocked threadpool requests; the probe gets an explicit timeoutSeconds (default 5, gateway.readinessProbeTimeoutSeconds). Removes the ejection mechanism that took the fleet to 11/60 Ready. No livenessProbe is added: restarting a saturated pod discards in-flight work. Validated under genuine saturation (335 in-flight/pod, 2x the thread budget): /readyz flat at ~0.45s, zero ejections. Multi-MB response serialization was separately measured against the probe (152 rps of 5MB responses per worker, /readyz p99 62ms).

Async-task polls off the shared threadpool (app). GET /v1/async-tasks/{task_id} does a blocking result-backend read and previously ran in the shared anyio threadpool. Now async, dispatching the read through a dedicated CapacityLimiter(40) per worker, so poll volume cannot starve other routes or the probe.

Per-pod proxy rate limits (chart, values-gated, default off). EnvoyFilter installing local_ratelimit on the gateway sidecars, inbound, with per-pod token buckets driven by a rateLimits.routes list. Defaults: GET /v1/async-tasks 50 rps/pod (burst 100), POST 20 rps/pod (burst 40). GET is sized for bytes, not just requests: polls return task results inline (postmortem root cause 2), so 50 rps/pod bounds worst-case egress at ~265MB/s/pod against the ~500MB/s node NIC knee; revisit upward once MLI-8311 replaces inline results with presigned URLs. Overflow 429s at the proxy (x-local-rate-limited: true) without consuming a gateway worker. Envoy 1.23 compatibility was verified live: actions sit at HTTP_ROUTE (1.23 ignores virtual-host rate limits) and the inbound vhost is named from the Service port; both were silent no-ops when configured otherwise.

Per-user rate limiting (app, config-gated, default off). Redis-backed fixed-window limiter as a per-route FastAPI dependency (user_rate_limit(route_class)), using the existing aioredis pool. Atomic Lua INCR+EXPIRE; 429 + Retry-After. Configured via user_rate_limits in the service config with a log-only rollout mode. Fails open on any Redis error or a check exceeding 100ms, with a circuit breaker (5 consecutive failures opens a 10s cooldown) so a Redis brownout cannot become a reconnect storm. Every decision emits model_engine.user_rate_limit.decision (dogstatsd) tagged user_id/route_class/outcome: per-tenant volume plus enforcement telemetry, including visibility of fail-open windows. Intended prod values: 200 rps/user on polls, 25 rps/user on submits (no endpoint has been observed to drain faster than ~24 tasks/s). Validated at those values: offending user pinned at exactly 200 rps, control user 99.7% clean.

Retry policy (chart). retryOn: connect-failure,unavailable,502,504 — 503 (the overload signal) is no longer retried; disabling that retry live during the incident cut envoy rps 68 to 39. Configurable under gateway.retries; perTryTimeout unset by default because the route carries streaming requests.

Sidecar CPU request (chart). sidecar.istio.io/proxyCPU: 250m on gateway pods; without it, incident scale-outs starved sidecars on CPU-packed nodes and reduced capacity.

Forwarders. Explicit configurable timeout on the sync forwarder (MLI-8206) and a Celery forwarding deadline.

Observability/docs. Regenerated _istio-attribute-match-conditions.tpl from the live OpenAPI schema (adds v2 LLM routes to per-route istio metrics; verified tagging live). Docs gain async-task polling guidance (jittered backoff, bounded outstanding set).

Known limitations

  • DB connection posture is unchanged from main (an earlier lazy-engine rewrite was reverted in this branch). Reader-connection protection comes from MLI-8244 (pending max_connections raise 625 to 2000) plus the POST rate limits.
  • Per-user limits key on the resolved user_id. Prod currently falls back to FakeAuthenticationRepository (plugins package unimportable), so identities are rotatable and credential-sharing workloads share one budget; the identity-blind per-pod buckets are the backstop. Separate security work item.
  • Above the Envoy fleet ceiling (limit x Ready pods), aggregate shedding is identity-blind and well-behaved tenants share the 429s. At the prod floor (1,500 rps GET) this regime is above the incident's peak load.
  • Rollback caution: a failed helm upgrade followed by rollback can orphan the EnvoyFilter from helm management while it stays active on-cluster. Verify kubectl get envoyfilter after any rollback of this release.

Verification

Full test plan and append-only evidence log maintained alongside MLI-8236: unit suite green at HEAD (835 passed), chart render battery, Envoy 1.23 enforcement burst tests at the shipped numbers, clean saturation run with recorded concurrency witness, per-user enforcement at prod values with tenant isolation, fail-open + breaker exercised against a real dying Redis with the decision metric confirming each phase in Datadog, and an end-to-end async task loop (submit, worker, S3 result, poll SUCCESS) on the branch build. Two external review rounds plus two adversarial testing reviews; all findings addressed or documented above.

🤖 Generated with Claude Code

lorenzo-norcini-scale and others added 10 commits August 14, 2026 14:22
…meout

A sync healthcheck handler runs in the anyio threadpool, so under load the
/readyz probe queues behind blocked requests, misses the 1s default probe
timeout, and k8s ejects pods that are saturated but healthy. Making the
handler async keeps the probe on the event loop, and the probe timeout is
now explicit (default 5s) and values-configurable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ry policy configurable

gateway-error retried 502/503/504 as a bundle, so during overload every 503
was retried 3 more times, multiplying offered load exactly when the fleet was
saturated. The default policy now retries connection failures and 502/504
only, and attempts/retryOn/perTryTimeout are values-configurable.
perTryTimeout stays unset by default because the single route also carries
streaming and long-lived requests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sidecar had memory annotations only. On nodes at full CPU request
capacity an unrequested sidecar is starved during bootstrap, its postStart
hook hangs, and new gateway pods never become Ready, which turns scale-out
into negative capacity. Request 250m by default (configurable, no limit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sync http-forwarder posts to the local inference server without an
explicit aiohttp timeout, so the client default of total=300s applies.
Non-streaming generations that take longer than 5 minutes are cut off
with a 500 while the inference server keeps computing the response.

Add a timeout_seconds field to Forwarder and LoadForwarder (default
3600s), overridable per deployment via forwarder.sync.timeout_seconds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reject zero, negative, non-finite, and non-numeric values when the
forwarder config is loaded, instead of letting them reach
aiohttp.ClientTimeout at request time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GET /v1/async-tasks/{task_id} was a sync handler doing a blocking
result-backend read (S3 on AWS) in the shared anyio threadpool. Poll volume
scales with outstanding tasks, so a large backlog fills the pool and every
other sync route queues behind it. The handler is now async and dispatches
the blocking read through its own CapacityLimiter(40), isolating polls from
the rest of the threadpool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…st use

DBManager eagerly built five engines per process (sync/async x RW/RO plus a
NullPool engine), so every gateway worker held idle pools for engines it
never uses; only the async pair is used on the API path. At 4 workers per
pod this multiplied idle Postgres connections across the fleet and made
scale-out storm the reader's connection limit. Engines are now created on
first use per kind; credential-expiry refresh disposes and rebuilds only the
kinds actually in use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nt clamps

Adds a values-gated (default off) EnvoyFilter installing
envoy.filters.http.local_ratelimit on the gateway sidecars, inbound:

- Per-pod token buckets for GET /v1/async-tasks/{task_id} and
  POST /v1/async-tasks. Overflow 429s at the proxy and never consumes a
  gateway worker, so overload degrades to fast rejections instead of
  queueing collapse. Per-pod semantics scale the fleet ceiling with the HPA.
- A throttledTenants values list clamps named callers on those routes,
  matching both accepted Authorization forms (Basic base64 prefix and
  Bearer), replacing hand-authored VirtualService fault injection during
  incidents.

Traffic matching no descriptor is unaffected (large default bucket,
always_consume_default_token_bucket: false).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-open

Adds a Redis-backed per-user, per-route-class rate limiter enforced inside
verify_authentication, where identity is resolved, using the existing
aioredis pool. Fixed 1-second windows keyed on (route class, user_id);
rejections return 429 with Retry-After before the request reaches a handler
or the threadpool.

Disabled unless user_rate_limits is set in the service config. enforce:
false gives a log-only rollout mode that counts and logs would-be
throttles without rejecting. The limiter fails open on any Redis error or
if the check exceeds 100ms, so enforcement can never add an availability
dependency.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tanding set)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread charts/model-engine/templates/istio-ratelimit-envoyfilter.yaml Outdated
Comment thread model-engine/model_engine_server/db/base.py Outdated
- Rate limiter moves from inside verify_authentication to per-route FastAPI
  dependency composition (user_rate_limit(route_class)); drops the hardcoded
  method/path classification table and keeps auth single-purpose.
- Cached Redis client per pool instead of a client per request; INCR with
  conditional EXPIRE instead of a pipeline; fail-open log sampled to once
  per minute (a Redis outage fires it per request otherwise).
- Tenant clamps in the EnvoyFilter are scoped to the async-task routes via a
  :path match (previously they clamped every gateway route); route buckets and
  tenant actions are now data-driven from a values route list; workload
  selector reuses the gateway selector helper.
- Chart defaults live only in values.yaml (inline template defaults removed);
  retry policy values moved under gateway.retries.
- Task-poll thread limiter memoized with functools.cache; docs polling
  example uses the guide's existing tenacity idiom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread charts/model-engine/templates/istio-ratelimit-envoyfilter.yaml Outdated
INCR and EXPIRE run in one Lua eval so a partial failure cannot leave a
counter key without a TTL; the log-only over-limit warning is sampled per
(user, route class) alongside the fail-open log so a noisy tenant cannot
generate per-request log volume during rollout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread model-engine/model_engine_server/inference/forwarding/forwarding.py
lorenzo-norcini-scale and others added 3 commits August 14, 2026 17:24
… DB build lock

- Drop always_consume_default_token_bucket from the local_ratelimit config:
  the field does not exist before Envoy 1.24 (prod sidecars run Istio 1.15 /
  Envoy 1.23), and an unknown field makes istiod skip the whole patch
  silently, turning the rate limit into a no-op. The default bucket is large
  enough that always consuming it is harmless.
- Template the inbound vhost match from service.port instead of hardcoding
  80, and fail rendering when a throttledTenants userId length is not
  divisible by 3 (the Basic base64-prefix match only works then; anything
  else would half-apply silently).
- Rate limiter gains a circuit breaker (5 consecutive failures opens a 10s
  cooldown): each timed-out check abandons its pooled connection, so
  per-request checks against a slow Redis become a reconnect storm on the
  shared cache pool without one.
- DBManager session builds are serialized with a lock (called from both the
  event loop and threadpool threads; a cold-start race built duplicate
  engines and leaked the loser), and DBManager gets its first unit tests:
  lazy per-kind construction, credential-expiry rebuild, concurrent first
  use builds once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread model-engine/model_engine_server/db/base.py Outdated
lorenzo-norcini-scale and others added 3 commits August 17, 2026 15:54
… 1.15

Reverts the workload-port guess: Envoy config_dump on ml-training-new
(istiod 1.15.0) shows inbound|http|80 and no inbound|http|5000; with the
wrong name the VIRTUAL_HOST patch skips silently and no route or tenant
bucket ever fires (observed live: filter present, rate_limits absent,
zero 429s under burst). Same naming verified on Istio 1.30 locally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Envoy 1.23's local_ratelimit only consults route-level rate-limit policies;
virtual-host-level support (vh_rate_limits) arrived in 1.24+. On Istio 1.15
sidecars the VIRTUAL_HOST patch merged cleanly into config (verified in
config_dump) but the filter never produced descriptors, so no bucket ever
fired. Observed live on ml-training-new: filter and vhost rate_limits both
present, zero 429s, zero filter stats. Route-level actions work on both
1.23 and current Envoy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread model-engine/model_engine_server/inference/forwarding/forwarding.py
lorenzo-norcini-scale and others added 3 commits August 17, 2026 18:34
…kind matrix, forwarder timeout plumbing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…running

asyncio.run() in the sync getter raises RuntimeError when called from a
thread with a running event loop (sync session access inside async code);
dispose the underlying sync engine directly in that case. Adds the DB-7
parametrized test covering both call contexts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Envoy header_value_match on 'Bearer <id>'/'Basic <b64>' was case-sensitive on
the whole value, but FastAPI accepts the scheme case-insensitively, so a
throttled caller sending 'bearer <id>' bypassed the clamp entirely (verified
live on training: canonical Bearer 429'd, lowercase bearer passed 12/12).
Match the scheme with a case-insensitive regex group and the credential
exactly (base64/token stays case-sensitive).
lorenzo-norcini-scale and others added 7 commits August 18, 2026 13:40
…ve OpenAPI schema

Adds the v2 LLM routes (and other drift) missing since the tpl was last
generated; per-route istio metrics are how rate limits get sized.
The 15/5 rps-per-pod buckets were anchored to the pre-hardening fleet's
degradation points (10.7 healthy / 18.3 knee rps/pod), which were symptoms
of the threadpool metastability this branch removes, not capacity. The
hardened gateway measured ~577 rps/pod of passing poll traffic at p99 36ms,
and sustained slow-poll overload now degrades to fast shedding rather than
collapse. Buckets become a runaway-storm ceiling (GET 100/pod burst 200,
POST 20/pod burst 40); per-tenant fairness remains the app-level limiter's
job.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The postmortem's RC2 established the incident's binding resource was node
NIC egress from multi-MB inline poll results (~500MB/s/pod knee). At
100 rps/pod the bucket admits ~530MB/s/pod worst-case, i.e. no byte
protection. 50 rps/pod bounds worst-case egress at ~265MB/s/pod while
still admitting ~15x baseline (fleet floor 1,500 rps ~= incident peak).
Revisit upward once MLI-8311 replaces inline results with presigned URLs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Emits model_engine.user_rate_limit.decision (dogstatsd counter) tagged
user_id/route_class/outcome on every limiter decision (allowed,
would_throttle, throttled, fail_open, breaker_open). outcome:allowed is
the per-tenant volume signal on the rate-limited routes; the throttle
outcomes are enforcement telemetry, including detection of the limiter
itself being inactive. Replaces the postmortem's proposed raw per-tenant
request-volume monitor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lorenzo-norcini-scale lorenzo-norcini-scale changed the title Launch gateway hardening: probes, retries, sidecar CPU, DB pools, rate limiting Launch gateway hardening: probes, retries, rate limits, forwarding deadlines Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant